Mestre JavaScripts iterator-protokoll. Lær å gjøre objekter itererbare, kontrollere `for...of`-løkker, og implementere tilpasset iterasjonslogikk.
Lås opp tilpasset iterasjon i JavaScript: En dypdykk i iterator-protokollen
Iterasjon er et av de mest grunnleggende konseptene innen programmering. Fra å behandle listeelementer til å lese datastrømmer, jobber vi konstant med sekvenser av informasjon. I JavaScript har vi kraftige og elegante verktøy som for...of-løkken og spread-syntaksen (...) som gjør iterasjon over innebygde typer som Arrayer, strenger og Maps til en sømløs opplevelse.
Men har du noen gang stoppet opp og lurt på hva som gjør disse objektene så spesielle? Hvorfor kan du skrive for (const char of "hello"), men ikke for (const prop of {a: 1, b: 2})? Svaret ligger i en kraftig, men ofte misforstått, funksjon i ECMAScript-standarden: iterator-protokollen.
Denne protokollen er ikke bare en intern mekanisme for JavaScripts innebygde objekter. Det er en åpen standard, en kontrakt som ethvert objekt kan adoptere. Ved å implementere denne protokollen, kan du lære JavaScript hvordan den skal iterere over dine egne tilpassede objekter, og gjøre dem til førsteklasses borgere i språket. Du kan låse opp den samme syntaktiske elegansen som for...of for dine egne datastrukturer, enten det er et binært tre, en lenket liste, en spillsekvens eller en tidslinje med hendelser.
I denne omfattende guiden vil vi avmystifisere iterator-protokollen. Vi vil bryte den ned i sine kjernekomponenter, gå gjennom bygging av tilpassede iteratorer fra bunnen av, utforske avanserte bruksområder som uendelige sekvenser, og til slutt oppdage den moderne, forenklede tilnærmingen ved hjelp av generatorfunksjoner. Ved slutten vil du ikke bare forstå hvordan iterasjon fungerer under panseret, men også være i stand til å skrive mer uttrykksfull, gjenbrukbar og idiomatisk JavaScript-kode.
Kjernen i iterasjon: Hva er JavaScripts iterator-protokoll?
Først er det avgjørende å forstå at "iterator-protokollen" ikke er en enkelt klasse du utvider eller en spesifikk funksjon du kaller. Det er et sett med regler eller konvensjoner som et objekt må følge for å bli ansett som "itererbart" og for å produsere en "iterator". Det er best å tenke på det som en kontrakt. Hvis objektet ditt signerer denne kontrakten, lover JavaScript-motoren at den vet hvordan den skal løkke over det.
Denne kontrakten er delt inn i to distinkte deler:
- Den itererbare protokollen: Denne bestemmer om et objekt er itererbart i utgangspunktet.
- Iterator-protokollen: Denne definerer mekanikken for hvordan objektet vil bli iterert over, én verdi om gangen.
La oss undersøke hver del av denne kontrakten i detalj.
Første halvdel av kontrakten: Den itererbare protokollen
Den itererbare protokollen er overraskende enkel. Den har bare ett krav:
Et objekt anses som itererbart hvis det har en spesifikk, velkjent egenskap som gir en metode for å hente en iterator. Denne velkjente egenskapen aksesseres ved hjelp av Symbol.iterator.
Så, for at et objekt skal være itererbart, må det ha en metode tilgjengelig via nøkkelen [Symbol.iterator]. Når denne metoden kalles, må den returnere et iterator-objekt (som vi vil dekke i neste seksjon).
Du spør kanskje: "Hva er Symbol, og hvorfor ikke bare bruke et strengnavn som 'iterator'?" Et Symbol er en unik og uforanderlig primitiv datatype introdusert i ES6. Hovedformålet er å fungere som en unik nøkkel for objektegenskaper, for å forhindre utilsiktede navnekollisjoner. Hvis protokollen brukte en enkel streng som 'iterator', kunne din egen kode ha definert en egenskap med samme navn for et annet formål, noe som ville ført til uforutsigbare feil. Ved å bruke Symbol.iterator, garanterer språkspesifikasjonen en unik, standardisert nøkkel som ikke vil kollidere med annen kode.
Vi kan enkelt verifisere dette på innebygde itererbare objekter:
const anArray = [1, 2, 3];
const aString = "global";
const aMap = new Map();
console.log(typeof anArray[Symbol.iterator]); // "function"
console.log(typeof aString[Symbol.iterator]); // "function"
console.log(typeof aMap[Symbol.iterator]); // "function"
// A plain object is not iterable by default
const anObject = { a: 1, b: 2 };
console.log(typeof anObject[Symbol.iterator]); // "undefined"
Andre halvdel av kontrakten: Iterator-protokollen
Når et objekt har bevist at det er itererbart ved å tilby en [Symbol.iterator]()-metode, skifter fokuset til objektet den metoden returnerer: iteratoren. Iteratoren er den virkelige arbeidshesten; det er objektet som faktisk håndterer iterasjonsprosessen og produserer sekvensen av verdier.
Iterator-protokollen er også veldig rett frem. Den har ett krav:
Et objekt er en iterator hvis det har en metode kalt next(). Denne next()-metoden skal, når den kalles, returnere et objekt med to spesifikke egenskaper:
done(boolean): Denne egenskapen signaliserer statusen for iterasjonen. Den erfalsehvis det er flere verdier som kommer i sekvensen. Den blirtruenår iterasjonen er fullført.value(enhver type): Denne egenskapen inneholder den nåværende verdien i sekvensen. Nårdoneertrue, ervalue-egenskapen valgfri og inneholder vanligvisundefined.
La oss se på en frittstående, manuelt laget iterator for å se dette i praksis, helt adskilt fra ethvert itererbart objekt. Denne iteratoren vil simpelthen telle fra 1 til 3.
const manualCounterIterator = {
count: 1,
next: function() {
if (this.count <= 3) {
return { value: this.count++, done: false };
} else {
return { value: undefined, done: true };
}
}
};
// We call next() repeatedly to get each value
console.log(manualCounterIterator.next()); // { value: 1, done: false }
console.log(manualCounterIterator.next()); // { value: 2, done: false }
console.log(manualCounterIterator.next()); // { value: 3, done: false }
console.log(manualCounterIterator.next()); // { value: undefined, done: true }
console.log(manualCounterIterator.next()); // { value: undefined, done: true } - It stays done
Dette er den grunnleggende mekanismen som driver hver for...of-løkke. Når du skriver for (const item of iterable), gjør JavaScript-motoren følgende bak kulissene:
- Den kaller
[Symbol.iterator]()-metoden påiterable-objektet for å få en iterator. - Deretter kaller den gjentatte ganger
next()-metoden på den iteratoren. - For hvert returnerte objekt hvor
doneerfalse, tildeler denvaluetil løkkevariabelen din (item) og utfører løkkekroppen. - Når
next()returnerer et objekt hvordoneertrue, avsluttes løkken.
Bygge fra bunnen av: En praktisk guide til tilpasset iterasjon
Nå som vi forstår teorien, la oss sette den ut i praksis. Vi skal lage en tilpasset klasse kalt Timeline. Denne klassen vil håndtere en samling av historiske hendelser, og målet vårt er å gjøre den direkte itererbar, slik at vi kan løkke gjennom hendelsene i kronologisk rekkefølge.
Bruksområdet: En `Timeline`-klasse
Vår Timeline-klasse vil lagre hendelser, der hver hendelse er et objekt med en year og en description. Vi ønsker å kunne bruke en for...of-løkke for å iterere gjennom disse hendelsene, sortert etter år.
class Timeline {
constructor() {
this.events = [];
}
addEvent(year, description) {
this.events.push({ year, description });
}
}
const myTimeline = new Timeline();
myTimeline.addEvent(1995, "JavaScript is created");
myTimeline.addEvent(2009, "Node.js is introduced");
myTimeline.addEvent(1997, "ECMAScript standard is first published");
myTimeline.addEvent(2015, "ES6 (ECMAScript 2015) is released");
// Goal: Make the following code work
// for (const event of myTimeline) {
// console.log(`${event.year}: ${event.description}`);
// }
Steg-for-steg-implementering
For å nå målet vårt, må vi implementere iterator-protokollen. Dette betyr å legge til [Symbol.iterator]()-metoden i vår Timeline-klasse.
Denne metoden må returnere et nytt objekt – iteratoren – som vil inneholde next()-metoden og håndtere tilstanden til iterasjonen (f.eks. hvilken hendelse vi er på). Det er et kritisk designprinsipp at iterasjonstilstanden skal leve på iteratoren, ikke det itererbare objektet selv. Dette tillater flere, uavhengige iterasjoner over samme tidslinje samtidig.
class Timeline {
constructor() {
this.events = [];
}
addEvent(year, description) {
// Vi legger til en enkel sjekk for å sikre dataintegritet
if (typeof year !== 'number' || typeof description !== 'string') {
throw new Error("Invalid event data");
}
this.events.push({ year, description });
}
// Steg 1: Implementer den itererbare protokollen
[Symbol.iterator]() {
// Sorter hendelsene kronologisk for iterasjon.
// Vi lager en kopi for ikke å endre rekkefølgen i den opprinnelige arrayen.
const sortedEvents = [...this.events].sort((a, b) => a.year - b.year);
let currentIndex = 0;
// Steg 2: Returner iterator-objektet
return {
// Steg 3: Implementer iterator-protokollen med next()-metoden
next: () => { // Bruker en pilfunksjon for å fange opp `sortedEvents` og `currentIndex`
if (currentIndex < sortedEvents.length) {
// Det er flere hendelser å iterere over
const currentEvent = sortedEvents[currentIndex];
currentIndex++;
return { value: currentEvent, done: false };
} else {
// Vi har nådd slutten av hendelsene
return { value: undefined, done: true };
}
}
};
}
}
Se magien: Bruk av vår tilpassede itererbare
Med protokollen korrekt implementert, er vårt Timeline-objekt nå et fullverdig itererbart objekt. Det integreres sømløst med JavaScripts iterasjonsbaserte språkfunksjoner. La oss se det i aksjon.
const myTimeline = new Timeline();
myTimeline.addEvent(1995, "JavaScript is created");
myTimeline.addEvent(2009, "Node.js is introduced");
myTimeline.addEvent(1997, "ECMAScript standard is first published");
myTimeline.addEvent(2015, "ES6 (ECMAScript 2015) is released");
console.log("--- Bruker for...of-løkke ---");
for (const event of myTimeline) {
console.log(`${event.year}: ${event.description}`);
}
// Output:
// 1995: JavaScript is created
// 1997: ECMAScript standard is first published
// 2009: Node.js is introduced
// 2015: ES6 (ECMAScript 2015) is released
console.log("\n--- Bruker spread-syntaks ---");
const eventsArray = [...myTimeline];
console.log(eventsArray);
// Output: An array of the event objects, sorted by year
console.log("\n--- Bruker Array.from() ---");
const eventsFrom = Array.from(myTimeline);
console.log(eventsFrom);
// Output: An array of the event objects, sorted by year
console.log("\n--- Bruker destrukturerende tildeling ---");
const [firstEvent, secondEvent] = myTimeline;
console.log(firstEvent);
// Output: { year: 1995, description: 'JavaScript is created' }
console.log(secondEvent);
// Output: { year: 1997, description: 'ECMAScript standard is first published' }
Dette er den sanne kraften i protokollen. Ved å følge en standardkontrakt har vi gjort vårt tilpassede objekt kompatibelt med et stort utvalg av eksisterende og fremtidige JavaScript-funksjoner uten ekstra arbeid.
Videreutvikle dine iterasjonsferdigheter
Nå som du har mestret det grunnleggende, la oss utforske noen mer avanserte konsepter som gir deg enda større kontroll og fleksibilitet.
Viktigheten av tilstand og uavhengige iteratorer
I vårt Timeline-eksempel var vi veldig nøye med å plassere tilstanden til iterasjonen (currentIndex og sortedEvents-kopien) inne i iterator-objektet som returneres av [Symbol.iterator](). Hvorfor er dette så viktig? Fordi det sikrer at hver gang vi starter en iterasjon, får vi en *ny, uavhengig iterator*.
Dette gjør at flere konsumenter kan iterere over det samme itererbare objektet uten å forstyrre hverandre. Tenk deg om currentIndex var en egenskap på selve Timeline-instansen – det ville blitt kaos!
const sharedTimeline = new Timeline();
sharedTimeline.addEvent(1, 'Event A');
sharedTimeline.addEvent(2, 'Event B');
sharedTimeline.addEvent(3, 'Event C');
const iterator1 = sharedTimeline[Symbol.iterator]();
const iterator2 = sharedTimeline[Symbol.iterator]();
console.log(iterator1.next().value); // { year: 1, description: 'Event A' }
console.log(iterator2.next().value); // { year: 1, description: 'Event A' } (Starter sin egen iterasjon)
console.log(iterator1.next().value); // { year: 2, description: 'Event B' } (Upåvirket av iterator2)
Uendelige løkker: Lage endeløse sekvenser
Iterator-protokollen krever ikke at en iterasjon noen gang må ta slutt. done-egenskapen kan simpelthen forbli false for alltid. Dette lar oss modellere uendelige sekvenser, noe som kan være utrolig nyttig for oppgaver som å generere unike ID-er, lage strømmer av tilfeldige data eller modellere matematiske sekvenser.
La oss lage en iterator som genererer Fibonacci-sekvensen i det uendelige.
const fibonacciSequence = {
[Symbol.iterator]() {
let a = 0, b = 1;
return {
next() {
[a, b] = [b, a + b];
return { value: a, done: false };
}
};
}
};
// Vi kan ikke bruke spread-syntaks eller Array.from() her, da det ville skapt en uendelig løkke og krasjet!
// const fibArray = [...fibonacciSequence]; // FARE: Uendelig løkke!
// Vi må konsumere den forsiktig, og sette vår egen avslutningsbetingelse.
console.log("De første 10 Fibonacci-tallene:");
let count = 0;
for (const number of fibonacciSequence) {
console.log(number);
count++;
if (count >= 10) {
break; // Det er avgjørende å bryte ut av løkken!
}
}
Valgfrie iterator-metoder: `return()`
For mer avanserte scenarioer, spesielt de som involverer ressursstyring (som filhåndtak eller nettverkstilkoblinger), kan en iterator valgfritt ha en return()-metode. Denne metoden kalles automatisk av JavaScript-motoren hvis iterasjonen stoppes for tidlig. Dette kan skje hvis en `break`-, `return`- eller `throw`-setning avslutter en `for...of`-løkke før den er fullført.
Dette gir iteratoren din en sjanse til å utføre oppryddingsoppgaver.
function createResourceIterator() {
let resourceIsOpen = true;
console.log("Ressurs åpnet.");
let i = 0;
return {
next() {
if (i < 3) {
return { value: ++i, done: false };
} else {
console.log("Iterator avsluttet naturlig.");
resourceIsOpen = false;
console.log("Ressurs lukket.");
return { done: true };
}
},
return() {
if (resourceIsOpen) {
console.log("Iterator avsluttet tidlig. Lukker ressurs.");
resourceIsOpen = false;
}
return { done: true }; // Må returnere et gyldig iterator-resultat
}
};
}
console.log("--- Scenario med tidlig avslutning ---");
const resourceIterable = { [Symbol.iterator]: createResourceIterator };
for (const value of resourceIterable) {
console.log(`Behandler verdi: ${value}`);
if (value > 1) {
break; // Dette vil utløse return()-metoden
}
}
Merk: Det finnes også en throw()-metode for feilpropagering, men den brukes primært i sammenheng med generatorfunksjoner, som vi skal diskutere nå.
Den moderne tilnærmingen: Forenkling med generatorfunksjoner
Som vi har sett, krever manuell implementering av iterator-protokollen nøye tilstandshåndtering og standardkode for å lage iterator-objektet og returnere { value, done }-objektene. Selv om det er essensielt å forstå denne prosessen, introduserte ES6 en mye mer elegant løsning: generatorfunksjoner.
En generatorfunksjon er en spesiell type funksjon som kan pauses og gjenopptas, noe som lar den produsere en sekvens av verdier over tid. Det forenkler opprettelsen av iteratorer enormt.
Nøkkelsyntaks:
function*: Stjernen erklærer en funksjon som en generator.yield: Dette nøkkelordet pauser generatorens utførelse og "gir" en verdi. Når iteratorensnext()-metode kalles igjen, gjenopptas funksjonen der den slapp.
Når du kaller en generatorfunksjon, kjøres ikke koden i funksjonen umiddelbart. I stedet returnerer den et iterator-objekt som er fullt kompatibelt med protokollen. JavaScript-motoren håndterer automatisk tilstandsmaskinen, next()-metoden og opprettelsen av { value, done }-objektene for deg.
Refaktorering av vårt `Timeline`-eksempel
La oss se hvor dramatisk generatorfunksjoner kan forenkle vår Timeline-implementering. Logikken forblir den samme, men koden blir langt mer lesbar og mindre feilutsatt.
class Timeline {
constructor() {
this.events = [];
}
addEvent(year, description) {
this.events.push({ year, description });
}
// Refaktorert med en generatorfunksjon!
*[Symbol.iterator]() { // Stjernen gjør dette til en generatormetode
// Lag en sortert kopi
const sortedEvents = [...this.events].sort((a, b) => a.year - b.year);
// Gå gjennom de sorterte hendelsene
for (const event of sortedEvents) {
// yield pauser funksjonen og returnerer verdien
yield event;
}
// Når funksjonen er ferdig, blir iteratoren automatisk merket som 'done'
}
}
// Bruken er nøyaktig den samme, men implementeringen er renere!
const myGenTimeline = new Timeline();
myGenTimeline.addEvent(2002, "The Euro currency is introduced");
myGenTimeline.addEvent(1998, "Google is founded");
for (const event of myGenTimeline) {
console.log(`${event.year}: ${event.description}`);
}
Se på forskjellen! Den komplekse manuelle opprettelsen av iterator-objektet er borte. Tilstanden (hvilken hendelse vi er på) håndteres implisitt av den pausede tilstanden til generatorfunksjonen. Dette er den moderne, foretrukne måten å implementere iterator-protokollen på.
Kraften i `yield*`
Generatorfunksjoner har en annen superkraft: yield* (yield star). Dette lar en generator delegere iterasjonsprosessen til et annet itererbart objekt. Det er et utrolig kraftig verktøy for å komponere iteratorer fra flere kilder.
Tenk deg at vi har en `Project`-klasse som har flere `Timeline`-objekter (f.eks. en for design, en for utvikling). Vi kan gjøre `Project`-klassen selv itererbar, og den vil sømløst iterere over alle hendelser fra alle sine tidslinjer i rekkefølge.
class Project {
constructor(name) {
this.name = name;
this.designTimeline = new Timeline();
this.devTimeline = new Timeline();
}
*[Symbol.iterator]() {
console.log(`Itererer gjennom hendelser for prosjekt: ${this.name}`);
console.log("--- Design-hendelser ---");
yield* this.designTimeline; // Deleger til design-tidslinjens iterator
console.log("--- Utviklings-hendelser ---");
yield* this.devTimeline; // Deleger deretter til utviklingstidslinjens iterator
}
}
const websiteProject = new Project("Global Website Relaunch");
websiteProject.designTimeline.addEvent(2023, "Initial wireframes created");
websiteProject.designTimeline.addEvent(2024, "Final brand guide approved");
websiteProject.devTimeline.addEvent(2024, "Backend API developed");
websiteProject.devTimeline.addEvent(2025, "Frontend deployment");
for (const event of websiteProject) {
console.log(` - ${event.year}: ${event.description}`);
}
Det store bildet: Hvorfor iterator-protokollen er en hjørnestein i moderne JavaScript
Iterator-protokollen er langt mer enn en akademisk kuriositet eller en funksjon for bibliotekforfattere. Det er et fundamentalt designmønster som fremmer interoperabilitet og elegant kode. Tenk på det som en universell adapter. Ved å få objektene dine til å overholde denne standarden, kobler du dem til et massivt økosystem av språkfunksjoner som er designet for å fungere med enhver sekvens av data.
Listen over funksjoner som er avhengige av den itererbare protokollen er omfattende og voksende:
- Løkker:
for...of - Array-oppretting/sammenslåing: Spread-syntaksen (
[...iterable]) ogArray.from(iterable) - Datastrukturer: Konstruktørene for
new Map(iterable),new Set(iterable),new WeakMap(iterable), ognew WeakSet(iterable)godtar alle itererbare objekter. - Asynkrone operasjoner:
Promise.all(iterable),Promise.race(iterable), ogPromise.any(iterable)opererer på en itererbar samling av Promises. - Destrukturering: Du kan bruke destrukturerende tildeling med enhver itererbar:
const [first, second] = myIterable; - Nye API-er: Moderne API-er som
Intl.Segmenterfor tekstsegmentering returnerer også itererbare objekter.
Når du gjør dine egne datastrukturer itererbare, aktiverer du ikke bare en `for...of`-løkke; du gjør dem kompatible med hele denne kraftige suiten av verktøy, noe som sikrer at koden din er både fremoverkompatibel og enkel for andre utviklere å bruke og forstå.
Konklusjon: Dine neste steg innen iterasjon
Vi har reist fra de grunnleggende reglene i de itererbare og iterator-protokollene til å bygge våre egne tilpassede iteratorer, og til slutt til den rene, moderne syntaksen i generatorfunksjoner. Du har nå kunnskapen til å lære JavaScript hvordan den skal traversere enhver datastruktur du kan forestille deg.
Å mestre denne protokollen er et betydelig skritt på din reise som JavaScript-utvikler. Det flytter deg fra å være en forbruker av språkets funksjoner til å bli en skaper som kan utvide språkets kjernefunksjoner for å passe dine spesifikke behov.
Handlingsrettede innsikter for globale utviklere
- Gå gjennom koden din: Se etter objekter i dine nåværende prosjekter som representerer en sekvens av data. Itererer du over dem med tilpassede, ikke-standard metoder som
.forEachItem()eller.getItems()? Vurder å refaktorere dem for å implementere standard iterator-protokoll for bedre interoperabilitet. - Omfavn lat evaluering: Bruk iteratorer, og spesielt generatorer, for å representere store eller til og med uendelige datasett. Dette lar deg behandle data ved behov, noe som fører til betydelige forbedringer i minneeffektivitet og ytelse. Du beregner bare det du trenger, når du trenger det.
- Prioriter generatorer: For ethvert nytt objekt du lager som skal være itererbart, gjør generatorfunksjoner (
function*) til ditt standardvalg. De er mer konsise, mindre utsatt for feil i tilstandshåndtering, og mer lesbare enn en manuell implementering. - Tenk i sekvenser: Begynn å se på programmeringsproblemer gjennom linsen av sekvenser. Kan en kompleks forretningsprosess, en datatransformasjonspipeline eller en overgang i brukergrensesnittets tilstand modelleres som en sekvens av trinn? I så fall kan en iterator være det perfekte, elegante verktøyet for jobben.
Ved å integrere iterator-protokollen i ditt utviklerverktøysett, vil du skrive renere, kraftigere og mer idiomatisk JavaScript som vil bli forstått og verdsatt av utviklere over hele verden.